Traffic replay + playbook schema - #105
Open
Rahul-Ganesan wants to merge 8 commits into
Open
Conversation
…e workload
Adds `gitm/traffic/` — canonical request schema, two adapters, a regime tagger,
replay through vLLM's native `bench serve`, and the join that ties a result back
to the trace that produced it.
No custom load generator. Replay goes through `--dataset-name timed_trace
--self-timed`, which schedules every request at its own timestamp. The `[vllm]`
extra floors at 0.23.0 because that dataset landed in bfb9ebc21 (2026-05-28), one
day after v0.22.0 shipped — checked at the tags, not inferred from a changelog.
Below the floor the run dies on an argparse complaint about an unknown dataset
name, which reads like a typo in our command rather than a missing feature.
Three real traces, pinned by sha256 through the existing gitm.bench.manifest.
BurstGPT_1 383/400 rows, in p50/p95 353/1638, D=1.01. BurstGPT_3 399/400,
309/1497 — its `Session ID` and `Elapsed time` are inserted at positions 1 and 2,
not appended, so a positional reader takes the session id as the model; columns
are read by name. Mooncake 400/400, 9075/49904, D=6.74. The two axes that matter
separate cleanly on real data, which is what makes them axes rather than
decoration.
Real data handling is enforced, not logged: 7 named drop reasons, each firing
exactly once against a CSV and a JSONL dirty fixture, and a trace whose counts do
not reconcile cannot be constructed. A caller's own filter counts apart from bad
data and still reconciles.
Fidelity is checked against the file vLLM will read, not an in-memory copy —
every statistic exactly 0.0 on both adapters. Fired end to end against real
`vllm bench serve` 0.28.0: 40/40 completed, paced to 12.008s against a 12.000s
trace span, confirmed by two independent clocks.
The failure this exists to catch: `--timed-trace-chunk-hash-size` defaults to 16
against Mooncake's 512-token blocks, making every prompt 32x short while
completed, duration, throughput and every percentile still read perfectly. Block
size verified over 1,546 rows rather than assumed. The emitter refuses to write
such a file; the joiner catches one fired from a plan built elsewhere.
Two result fields are wrong rather than missing: under `--self-timed` vLLM still
records the CLI's `request_rate` ("inf", a string) and `burstiness` (1.0) against
the trace's real 2.837 rps and D 6.74 — exactly the two axes a playbook row keys
on. Dropped with a stated reason, values kept visible. `unjoined_keys()` fails on
any new vLLM key neither kept nor deliberately dropped; it caught `rtfx` on its
first run.
Also `gitm/_banner.py` and its wiring into `gitm/cli.py` — a dependency of the new
CLI. stderr, and only when stdout is a TTY: the gate asks about stdout because
the question is whether a human is watching, and a banner on stdout surfaces as a
JSON parse error in a CI step, nowhere near the banner.
24 selftest checks, the same assertions as 25 pytest cases, ruff clean.
Knob and environment fields are left pending until the shared config-capture
schema exists.
Adds `gitm/playbook/` — what a promoted tuning result *is*, and when it may be applied to live traffic. The contract between detection and the apply runtime, so it ships as types rather than as a doc two implementations read differently. `match.py` is separate from `schema.py` because the match semantics, not the field list, is the part that gets argued about. Identity is a split. Exact equality on model+revision, GPU SKU, engine+version, source_kind, concurrency and the knob key set; distance on the numeric regime axes, because live traffic never lands on a measured point. Each gate is a named MatchPolicy field, so loosening one is an edit visible in a diff rather than an accident inside a comparison. The source_kind gate is asserted with every numeric axis identical, so only the gate can be doing the work — a scoreboard result never satisfies a production query. Distance is |log2(a/b)| on the raw axes: scale-free, symmetric, zero at equality, inf when one side is zero, because "no output tokens" is not a small version of "some". Burstiness uses the shifted ratio so a perfectly paced trace stays comparable instead of returning infinity. Six axes — the p95s are in because the p50s alone cannot separate a long-context workload from a short one with a heavy tail, which is exactly where prefix caching and chunked prefill live. Combined with L-infinity, not a mean: a row is as far away as its worst axis. Asserted on the case it exists for — identical on five axes and 8x off on input_p95 reads 0.5 as a mean, which applies the row, and 3.0 as L-inf, which does not. For scale the two real traces are 4.929 apart, limited by input_p95. rate_rps is deliberately not an axis and the exclusion is material rather than an omission: two regimes identical but for an 8x rate difference are 0.0 apart by default and 3.0 with the axis on. The inclusion rule and the experiment that would settle it are written down. No distance threshold ships. AxisTolerance refuses a max_distance without the run that produced it, and the shipped policy carries none. An exact regime match still returns a row, so the schema is usable today; any nonzero distance returns UNCALIBRATED, names the limiting axis, and routes to discovery. Calibration needs the same knob measured across nearby regimes, which needs a GPU — the cost of the open state is a discovery run, the cost of an invented threshold is a knob applied to traffic nobody measured it on. A row cannot be constructed without provenance: no knobs, repeats < 2, a delta missing its latency percentiles, or an empty trace_sha256 all fail. MeasuredDelta carries throughput and TTFT/ITL percentiles, so the schema cannot express a throughput-only row. Invalidation is a field with a reason, never a deletion. `evidence` separates measured from illustrative, because the worked examples ship in the same format as real rows. delta_is_floor falls out of the traces: BurstGPT has no prefix identity, so a replay synthesizes unique blocks per request and a prefix-cache knob measured there saw the least reuse the real traffic could have had. Its delta is a lower bound, never quotable as the gain. ex6 is that case; ex2 is the control. row_from_runs builds a row from two joined runs — one BenchRun is one arm, a row is the difference — refusing arms that did not run the same trace, any run that did not reconcile, unequal repeat counts, and a missing latency percentile. Medians, never means. The shipped examples are 6 rows, 0 selectable: regimes real off the pinned fixtures, deltas invented, every row saying so. A perfectly matching query returns nothing and names the reason per row. The largest claimed delta in the file is the scoreboard row, gated out by equality rather than distance. 19 selftest checks, the same assertions as 20 pytest cases, ruff clean.
The entry point for the two packages above. Leads with what is measured and what is not, because the distinction is load-bearing: the adapters, replay fidelity, firing at a live endpoint and the result join are measured; no playbook row is true, the regime-distance threshold is uncalibrated, and pacing under a saturating server needs a GPU. Nothing here claims a performance win. It is the machinery that would make one checkable.
Removed comments regarding version constraints and traffic library usage.
Removed comments related to CUDA and dependencies.
Removed banner argument handling from CLI parser.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Traffic replay + playbook schema
Two packages that together make a tuning claim checkable by someone who was not in
the room:
gitm/traffic/replays real production traces through a real endpointand ties the result back to the workload;
gitm/playbook/holds the row that saysthis knob, on this model and GPU, under this traffic, moved these numbers — and
decides when that row may be applied to live traffic.
CPU-only except for firing a replay. No new dependencies. One existing extra
moves:
vllm>=0.6→vllm>=0.23.0.PARASAIL_POC.mdis the reader's entry point. This body is what changed and why.What is measured, and what is not
Stated first because the distinction is load-bearing.
vllm bench serve0.28.0, 40/40, exit 0illustrative, deltas inventedNothing here claims a performance win. It is the machinery that would make one
checkable.
gitm/traffic/— replayschema.pyCanonicalRequest/TraceMeta/DropReason. Every field carries type, units, and what happens when a source lacks it. A trace with noTraceMetacannot be replayedadapters.py_1/_2and_3) and Mooncake. Read by column name, so a future_4loads rather than raisesregime.pylabel().source_kindkeeps a scoreboard workload from ever reading as production trafficreplay.pytimed_traceformat. No custom load generatorparameterize.py/xenvvalidate.pyrunner.pyresults.pybench serve's output back to the workload that produced itThe three traces, measured not assumed
Dprod/io1/in256/out128/burst-poisson/copenprod/io2/in256/out64/burst-poisson/copenprod/io32/in8k/out256/burst-hi/copenD 6.74 vs 1.01, input p50 9,075 vs 353 — the two axes that matter separate
cleanly on real data.
check_regime_axes_separate_the_tracesasserts it, becauseaxes that cannot tell two production traces apart are decoration.
BurstGPT_3insertsSession IDandElapsed timeat positions 1 and 2, notappended — a positional reader takes
Session IDas the model. On 5,643 real v3rows, session id is populated on exactly the
Conversation logrows and emptyon exactly the
API logrows: 90% of a real v3 trace is single-shot trafficwith no conversation, so an empty one is emitted as
session_id=Nonerather thandropped. Dropping would have discarded 5,115 of 5,643 rows.
Real data handling, enforced rather than logged
7 named drop reasons, each firing exactly once against both a CSV and a JSONL
dirty fixture. Counts land in
TraceMeta, and a trace whose counts do notreconcile cannot be constructed —
rows_read == rows_emitted + droppedin__post_init__. A caller's own filter counts separately from bad data and stillreconciles.
The zero-token rows are real and are the majority defect: 744 of 9,382
Conversation logrows carry 0/0; all 19API logrows are well-formed. Aproperty of the log, not a parse artefact.
Fidelity, against the file vLLM reads
Not against an in-memory copy — the emitter writes it,
read_timed_tracere-parses it,
compareputs the two side by side:Every statistic exactly 0.0, on both adapters.
Fired end to end against real vLLM 0.28.0: 40/40 completed, 12.008 s against a
12.000 s trace span, confirmed by two independent clocks — vLLM's own and the
receiving server's 11.996 s across 40 POSTs.
The silent failure this exists to catch
vLLM's
--timed-trace-chunk-hash-sizedefaults to 16; Mooncake's blocks are512. At the default every prompt is 32× short while
completed,duration, throughput and every percentile still read perfectly. Block sizeverified rather than assumed: over 1,546 Mooncake rows,
max(input_length / (len(hash_ids)·512)) = 1.0exactly — the blocks tile each prompt with no slack.The emitter refuses to write such a file; the joiner catches one fired from a plan
built elsewhere. On the real run
total_input_tokenscame back 506,280 against506,280 — at 16-token blocks it would have been ~15,821.
Two result fields are wrong, not just missing
--save-resultwrites 34 keys. Under--self-timedvLLM still records the CLI'srequest_rate("inf"— a string, sincejson.dumpscannot write bareInfinity) andburstiness(1.0), against the trace's real 2.837 rps and D6.74. Exactly the two axes a playbook row keys on.
Dropped with a stated reason, values kept visible, raw JSON kept whole. A new
vLLM field cannot fall out silently:
unjoined_keys()fails on any key neitherkept nor deliberately dropped — it caught
rtfxon its first run.gitm/playbook/— the rowmatch.pyis separate fromschema.pybecause the match semantics, not the fieldlist, is the part that gets argued about.
The identity split
Exact on model+revision, GPU SKU, engine+version,
source_kind, concurrencyand the knob key set. Distance on the numeric regime axes, because live
traffic never lands on a measured point. Each gate is a named
MatchPolicyfield,so loosening one is visible in a diff rather than an accident inside a comparison.
source_kindis asserted with every numeric axis identical, so only the gatecan be doing the work.
|log2(a/b)|, combined with L-infinityScale-free, symmetric, zero at equality,
infwhen one side is zero. Burstinessuses
|log2((1+a)/(1+b))|becauseD = 0is a real trace and a bare ratio wouldmake a perfectly paced trace incomparable to everything, including another paced
one.
Six axes. The p95s are in because the p50s alone cannot separate a
long-context workload from a short one with a heavy tail — which is exactly where
prefix caching and chunked prefill live.
L-inf, not a mean, asserted on the case it exists for: identical on five axes,
8× off on
input_p95reads 0.5 as a mean and 3.0 as L-inf. The meanapplies the row. For scale the two real traces are 4.929 apart, limited by
input_p95.rate_rpsis deliberately not an axis, and the exclusion is material ratherthan an omission: two regimes identical but for an 8× rate difference are 0.0
apart by default and 3.0 with the axis on. The inclusion rule and the
experiment that would settle it are written down.
No threshold ships
An exact regime match still returns a row, so the schema is usable today; any
nonzero distance returns
UNCALIBRATED, names the limiting axis, and routesto discovery. A placeholder cannot quietly become a production constant. The
four-step calibration procedure is in the module, so the open state has an exit.
Lifecycle
A row is refused at construction without provenance: no knobs,
repeats < 2, adelta missing its latency percentiles, an empty
trace_sha256.MeasuredDeltacarries throughput and TTFT/ITL percentiles — the schema cannot express a
throughput-only row. Invalidation is a field with a reason, never a deletion.
evidence ∈ {measured, illustrative}exists because the worked examples ship inthe same format as real rows; without it an example copied into a live playbook is
indistinguishable from a promoted one.
row_from_runsbuilds a row from two joined runs — aBenchRunis one arm, arow is the difference — refusing arms that did not run the same trace, any run
that did not reconcile, unequal repeat counts, and a missing latency percentile.
Medians, never means.
delta_is_floorBurstGPT has no prefix identity, so a replay synthesizes unique blocks per
request: lengths hold, and no sharing is invented that the source never had. A
prefix-cache knob measured there saw the least reuse the real traffic could
have had, so its delta is a lower bound — usable as "at least this much",
never quotable as the gain. A computed property, not a stored flag.
ex6is that case;ex2is the control — same synthesized trace, a knob that doesnot depend on reuse, so not a floor.
The examples ship nothing selectable
6 rows, 0 selectable. Regimes real, deltas invented, every row labelled. A
perfectly matching query returns nothing and names the reason per row. The
largest claimed delta in the file (+22%) is the scoreboard row — the one most
likely to be copied, gated out by equality rather than distance. Regenerated by
make_examples.pyand byte-identical on re-run.Verification
Assertions live once and run from both entry points, so the check a reader is told
about and the check CI runs are the same check.
test_every_check_is_registeredfails if acheck_*function is defined and leftout of the list.
Six fixtures pinned by sha256 through the existing
gitm.bench.manifest(
gitm.bench.manifest/v1), plus the realbench serveresult JSON — 7 files,150,941 bytes,
verify_manifestclean. Both trace fixtures are unmodifiedprefixes of published files, with their source URLs recorded.
Full suite: 11 failures, all pre-existing — module-for-module identical to the
list on clean
b2da5b6, all missing optional deps or importer goldens. None ingitm/trafficorgitm/playbook.Two bugs found by running it rather than reading it
runner.pyexecuted a barevllmfromPATH, which fails under anabsolute interpreter (a conda env used without activation — normal in CI and
WSL) and, worse, could have validated one install and run another:
check_vllm()readsimportlib.metadatafor this interpreter.vllm_executable()now derives the binary fromsys.executable.--tokenizerwas missing from the argv builder.bench servebuilds atokenizer from
--modeleven thoughtimed_tracesends pre-tokenized prompts,so any served name HuggingFace cannot resolve dies in
AutoTokenizer.from_pretrained— long after the endpoint answered. Bites a stubserver and any
--served-model-namerename.The one dependency change
vllm = ["vllm>=0.6"]→vllm = ["vllm>=0.23.0"], with the reasoning in thefile.
bench serve --dataset-name timed_tracelanded inbfb9ebc21(2026-05-28),one day after v0.22.0 shipped — established by checking the tags (v0.22.0 ❌,
v0.22.1 ❌, v0.23.0 ✅), not inferred from a changelog. Below the floor the run
dies on an argparse complaint about an unknown dataset name, which reads like a
typo in our command rather than a missing feature.
Not split into a separate
[traffic]extra: the library is CPU-only exceptfor firing, so an extra named for it would claim you need a multi-gigabyte CUDA
install to use something that does not need one. The
>=0.19.0"conflict" wasprose in three install commands, never a packaging constraint.
Also included
gitm/_banner.py(56 lines, stdlib) and its six-line wiring intogitm/cli.py.It is a dependency of both new CLIs. stderr, and only when
sys.stdout.isatty()— the gate asks about stdout even though the banner goes to stderr, because the
question is "is a human watching" and stdout is where the answer is.
check_banner_can_never_corrupt_stdoutasserts the non-TTY case is silent andstdout stays empty. The failure it guards is remote from its cause: a banner on
stdout surfaces as a JSON parse error in a CI step, nowhere near the banner.